Skip to content

fix(restart): say a restart Harper accepted is not back yet, never that it failed - #1899

Merged
dawsontoth merged 9 commits into
claude/1890-outcome-unknown-writesfrom
claude/1895-restart-accepted-not-back
Oct 13, 2026
Merged

dawsontoth merged 9 commits into
claude/1890-outcome-unknown-writesfrom
claude/1895-restart-accepted-not-back

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

A badger keeper saying the lighthouse lantern is coming back

Rebased and ported (2026-10-12). The stack is now linear on current stage with no merge commits, and this PR's loop changes are ported onto the cluster restart and rolling config loops #1861 and #1867 rewrote (getClusterRestartTargets, the 5.2+ set_configuration fan-out, the failed-cluster-load toast, Cancel during the instance check). The design is unchanged; what the port had to decide is under Port onto #1861/#1867, and four review rounds after it added the fixes listed under Review after the port.

⊙ Problem

A restart Harper accepted is reported as failed when the target is slow to answer again: restartInstance sends the restart, then watches for the target with user_info, and both steps rejected the same promise. So the Restart button toasts "Failed to restart instance.", the cluster restart and rolling config update count the member as not restarted, and the chat tools return success: false. Each one invites a second restart of a target that already went down once (#1895).

❓ Your call: is this the right size for the fix? Chosen: classify at the producer, keep watching for a second probe schedule, and stop the one-at-a-time loops at a member that is not back. The smaller alternative is a copy-only change ("Failed" → "couldn't confirm") on every caller, which would drop the refused/accepted distinction a user needs (a refused restart is safe to retry; an accepted one is not). Reversible by reverting this PR.

💡 Solution

Harper's answer to the restart request is its only verdict on the restart. restart returns RESTART_RESPONSE on the main thread and schedules the teardown 50ms later "so we give the HTTP thread a chance to return a response" (harper bin/restart.ts:116-188; a non-object result is wrapped as { message } at server/serverHelpers/serverUtilities.ts:163-165). restart_service runs through executeJob and answers { message: 'Restarting http', job_id } once its job thread has been launched (serverUtilities.ts:590-617, server/jobs/jobRunner.ts:92-94); a bad service is a 400 from jobs.ts:130-133. So a 2xx means accepted, and the probe that follows only tells "back" from "not back yet". (Read-only harper checkout at v5.3.1-158-g16e5c663c.)

Also fixed: both chat tools now replace their loading toast on any failure; before, "Restarting HTTP service..." / "Creating from template..." kept spinning, since a loading toast never dismisses itself.

⚠️ Look hardest: the producer boundary, where only a failure after the POST resolved becomes RestartUnconfirmedError; the shared TTL that has to outlast the whole watch; and, new with the port, where the config update's stop sits against the fan-out.

🔀 Port onto #1861/#1867

#1861 and #1867 replaced the loops this PR edited: both now plan their targets through getClusterRestartTargets, report a failed cluster load, and honor a Cancel clicked during the instance check; the rolling config update also fans set_configuration out from its first instance on Harper 5.2+. The old loop code is gone; this PR's intent is applied to the new code:

❓ Your call: after a fan-out, a stop at a member that is not back says "N other instances were not updated, to keep the cluster available" and then "Instances that were not restarted may already have the new configuration saved". Both are true (not restarted, possibly saved), but "not updated" reads slightly stronger than the fan-out allows. Rewording it to "not restarted" for the config update is a one-word change in describeNoReturn; I left the shared helper's verb alone.

⚖️ Alternatives

The planning review (codex) returned better-alternative-exists; its variant was adopted: keep the producer-owned typed error, but stop the rolling loops on it, keep watching inside one deadline, and give the chat tools a non-contradictory result.

  • Classify in each caller by inspecting which request failed: five call sites re-deriving it from serialized request data, and the global mutation toast could not know the error came from a restart at all.
  • Resolve instead of reject ({ data, answeredAgain: false }): fails open, since any caller not updated, and the mutation's global handler, would report an unconfirmed restart as "restarted!".
  • Just lengthen the window: moves the edge (compaction on start, bin/restart.ts:135-137, is unbounded) without changing what the edge says.

❓ Your call: the loops now stop at a member that accepted the restart and never came back (cluster restart, rolling config; adopted from the planning review's blocker: carrying on can leave a two-member cluster with no member serving). The cost: re-clicking Restart Cluster restarts every member again, including those already done. The alternative is the old carry-on. Reversible by deleting the breaks.

❓ Your call: the watch is now about twice as long (≈3.5 → ≈7 min before "Not back yet"), with a "Still restarting" notice at the halfway point, and the Restart button stays disabled throughout. Shorter means more false "not back" stops in the loops; longer means a longer spinner.

❓ Your call: not adopted, against every review round: counting a probe answered 401/403 as the target being back. Through central manager's proxy a 403 is CM refusing the caller (notAllowed() in central-manager src/resources/hdbInstance/HDBInstance.js:415-422) before it contacts the instance, so it says nothing about the target (recorded in DESIGN.md). The copy says Studio "couldn't confirm it came back", which stays true for any failed probe. Treating a direct client's 401/403 as back would need a direct-vs-proxied split in the probe.

❓ Your call: for an unanswered restart (#1897's outcome-unknown case), both loops now wait on that member and stop there if the follow-up watch gives up too, as for a member that is not back (review rounds 1 and 2; before, the cluster loop discarded the watch's rejection and the config update did not wait at all). The config update's case is the lower-stakes one: Harper replaces HTTP workers an eighth at a time with the replacements started first (harper server/threads/manageThreads.js:1403-1419), so a member normally keeps answering. The cost is a wait of up to about seven minutes per unanswered member. Reversible by dropping the two waits.

❓ Your call: a Cancel clicked while a member's set_configuration is in flight now stops before that member's restart, and the Cancelled report says the configuration may be pending there (raised by codex in rounds 1 and 2; the adjudicator called finishing the member in flight defensible too). This was #1867's ordering, so it changes merged behaviour. Reversible by deleting that break.

🔧 Changes

✅ Verification

End-to-end route: integration-style tests through the real restartInstance, a real instance client (getInstanceClient with a fake adapter), and the real React Query mutation error handler; the loops are exercised through their real target planning with restartInstance mocked to reach each outcome. Not run against a live instance.

  • Fails on base, passes here: the Restart-button test on base a35e2bed0 showed the bug's signature, two toasts Error / timeout of 3000ms exceeded and Error / Failed to restart instance. for a restart Harper answered 200; with the fix it shows one Not back yet toast and dismisses the loading toast. A second test holds the probe open past the first schedule and checks the mark is held (still held six minutes in, past the old TTL), the button pending, "Still restarting" shown, then success once it answers.
  • restartInstance.test.ts drives a real instance client: an accepted restart that never comes back rejects with RestartUnconfirmedError after exactly 24 probes on the 10s + 23×15s schedule, a slow target is still watched, the request's own 60s bound plus every probe and every wait fit inside RESTART_MARK_TTL_MS, refused / gateway / no-answer POSTs keep their own error, and RUM filtering is unchanged for wrapped timeouts and network errors.
  • useRestartClusterClick.test.tsx: stops at a member that is not back and names the ones left alone, goes on once a slow member answers (holding its mark and the cluster's past the old five minutes), still carries on past a refusal, names refused members beside the one not back, keeps the warning when Cancel is clicked, and still says Cancelled when nothing was uncertain. From the review rounds: a no-verdict member whose watch also gives up stops the loop, a Cancel after one names the members it left, and no toast after a Cancel offers Cancel again.
  • useRollingConfigUpdate.test.tsx: the same stop, refusal and Cancel cases, plus the port's two, a canary that is not back is never fanned out from and a stop after the fan-out keeps the pending-configuration note, and from the review rounds the stop at a no-verdict restart, no wait after a no-verdict write, Cancel during the write and no second Cancel. restartTools.test.ts pins both chat-tool results and toasts; queryClient.test.ts pins the shared copy.
  • Mutation checks on the ported code, each guard reverted locally and run against its tests: all 43 went red. The first pass found three guards no test held (the request's explicit timeout, which only shows with the client's same 60s default cleared; the marks' TTL, since sleep is mocked; the config update's failed count); each got a test in test(restart): pin … and then went red. Covered: the producer's wrap, timeout, probe replay opt-out, slow callback and its guard, second schedule and TTL budget; describeError and shouldToastOwnFailure; the button's TTL and toast; both loops' stop, classification, waiting toast, Cancel precedence, failed count, Cancel count, no-verdict stop and its copy, and progress toasts without Cancel; the cluster loop's two marks; the config update's pending note, wait gate, Cancel-during-write and its note; the shared failed sentence; and the chat tools' branches and toast.
  • Gates on the head: npx vitest run exit 0 (522 files, 5033 passed, 1 expected fail, 11 skipped; the pre-commit hook also ran the full suite on every commit), npx tsc -b exit 0, npx oxlint --format stylish . exit 0, npx dprint check exit 0, all on Node 24.21.0.

Stack: based on #1897 (claude/1890-outcome-unknown-writes) → #1891 → #1882 → stage, all linear on current stage.

🔁 Review after the port

Four rounds, each with codex (graded), gemini and the Harper domain adjudicator, every one independent=true. The Cursor legs contributed nothing: round 1's grok leg exited 143, and every other Cursor attempt failed no-receipt because the 1Password SSH agent refused its git fetch; rounds 3 and 4 pruned them as narrow deltas. Round 4 (delta, at the head): codex LGTM, adjudicator Surviving-Findings: none.

Taken

  • Round 1, codex major (kept major): the cluster loop waited on a member whose restart got no verdict, then discarded the watch's rejection and restarted the next member; a 504 after the restart ran, plus a member that stays down, left a two-member cluster with nobody serving. It now stops there, saying Studio couldn't confirm that member came back either.
  • Round 1, adjudicator nit: a Cancel after a no-verdict member produced "Outcome unknown" without saying Cancel skipped the rest. Both loops now name the members Cancel left.
  • Round 2, codex major twice (adjudicated minor, "fixable in the stack"): the config update moved on 3s after an unanswered restart_service, so a configuration that broke that member could reach the next one. It now waits and stops the same way; an unanswered set_configuration does not wait, since nothing restarted that member.
  • Round 2, codex major in rounds 1 and 2 (adjudicated minor, pre-existing in fix(clusters): Restart Cluster reports a failed cluster load instead of staying disabled #1867): a Cancel during set_configuration still restarted that member. Now stops before the restart, and says the configuration may be pending (❓ above).
  • Rounds 2 and 3, adjudicator minor / codex minor: after a Cancel, the slow-return, no-verdict and "carry on momentarily" toasts came back with a second Cancel button that did nothing new. One helper per loop now drops the action and says no other instance will be touched.
  • Every round, comment nits: narrating test comments and helper JSDoc dropped, restartInstance's JSDoc trimmed to its contract, one flag comment removed.

Rejected on evidence

  • "The watch outlives the progress toasts' durations" (codex, round 1): sonner 2.0.8 never starts an auto-close timer for a loading toast (dist/index.mjs:644), which the repo already relies on (useRestartInstanceClick.ts). The adjudicator dropped it as factually wrong.
  • "?? drops the pending-configuration note" (gemini, round 1): the note is appended outside the parenthesised (uncertain?.description ?? …); a test pins it and its mutation went red.
  • "A second watch after success holds the mark 10s" (gemini, rounds 1 and 3): holdUntilAnswering is set only in the catch, for a request that got no verdict.
  • "axiosRetry resends the restart POST" (gemini, round 2): the POST is sent once; axiosRetry wraps only the probes, and fix(api): replay a gateway error only for reads, so a write is never sent twice #1891's replay never repeats a write.
  • "Cancel aborts the watch into RestartUnconfirmedError" (gemini, round 2): Cancel only sets a flag; no signal reaches the watch.
  • Gemini's remaining comment nits: the comments kept state an invariant or a contract, as the adjudicator ruled.

The harness's "state-trace coverage gap" on the new Cancel count was checked by hand: a member lands in none of restarted / failed / unanswered only when Cancel stopped the loop before its restart (or after its check), and the count is zeroed when the loop stopped at a member that is not back. Both loops' Cancel-count tests pin it, and its mutation went red.

Closes #1895

🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Related PRs: #1897 overlaps (this PR's base), #1882 overlaps (base of the stack; shares the restart TTL), #1867 overlaps (merged; reworked both restart loops, ported onto here), #1861 overlaps (merged; reworked useRollingConfigUpdate and added the fan-out the stop now gates)
Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-grok(failed),cursor-composer(no-receipt),cursor-muse(no-receipt); declined=cursor-kimi; rounds=4; full=1 @ 6cd6fbc

Review-Attention: read ~6m (decisions: canceled-progress-copy, progress-helper-duplicated, cancel-mid-write-stops-before-restart, availability-stop-outranks-cancel, chat-tool-success-true, watch-length-8min, do-less-alternative) @ 6cd6fbc

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the RestartUnconfirmedError to handle scenarios where a restart request is successfully accepted by Harper, but Studio cannot confirm that the instance has recovered. This prevents these cases from being incorrectly flagged as failures, which could lead to redundant restarts. The changes update the restart logic, rolling configuration updates, and chat tools to handle this error gracefully with clearer user notifications, and include comprehensive unit and integration tests. There are no review comments to assess, and I have no additional feedback to provide.

@dawsontoth
dawsontoth added this pull request to stack #1900 October 11, 2026 21:24
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 77.37% 13288 / 17173
🔵 Statements 77.33% 14107 / 18242
🔵 Functions 72.24% 3499 / 4843
🔵 Branches 72.26% 10426 / 14428
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/instance/applications/components/Chat/tools/describeToolError.ts 100% 50% 100% 100%
src/features/instance/applications/components/Chat/tools/createApp/execute.ts 75% 50% 50% 75% 21-23, 75-91
src/features/instance/applications/components/Chat/tools/restartHTTPService/execute.ts 77.77% 100% 100% 77.77% 42-52
src/features/instance/operations/index.tsx 98.71% 88.15% 100% 98.7% 96
src/hooks/useRestartClusterClick.tsx 93.15% 96.15% 63.63% 92.85% 38, 84, 206, 217, 240
src/hooks/useRestartInstanceClick.ts 89.65% 73.07% 50% 89.65% 43, 84, 98
src/hooks/useRollingConfigUpdate.tsx 95.65% 98.61% 71.42% 95.5% 118, 260, 271, 295
src/integrations/api/instance/status/restartInstance.ts 100% 100% 100% 100%
src/integrations/api/instance/status/restartUnconfirmed.ts 100% 96.29% 100% 100%
src/react-query/queryClient.ts 98.18% 100% 85.71% 98.18% 121
Generated in workflow #2321 for commit 6cd6fbc by the Vitest Coverage Report Action

dawsontoth and others added 7 commits October 12, 2026 21:40
…at it failed

restartInstance sent the restart and then watched for the target with
user_info, and both steps rejected the same promise, so a target slow to
answer again read as a failed restart everywhere: "Failed to restart
instance.", "Only N of M instances restarted", and success: false to the
chat agent. Each of those invites a second restart of a target that
already went down once.

Harper's answer to the restart request is its only verdict: `restart`
answers before it tears down (bin/restart.ts schedules the teardown 50ms
out), and `restart_service` once its job has started (executeJob). So a
probe failure after a 2xx now rejects with RestartUnconfirmedError
(cause: the last probe error), shown as "Not back yet ... check its
status before you restart it again".

- The watch keeps going past the usual recovery time (a second probe
  schedule, no second settle) and calls onSlowToReturn once in between,
  so the Restart button and both loops say "still waiting" instead of
  giving up. Every restart mark's TTL comes from the same constants, and
  the probes skip the gateway replay, so no mark lapses mid-watch.
- The cluster restart and rolling config update stop at a member that
  never comes back instead of restarting the next one, which could leave
  no member serving, and report the members they left alone.
- createApp keeps success: true (the app exists) and restartHTTPService
  returns copy without the "Error:" prefix telling the agent not to
  restart again. Both now replace their progress toast on any failure,
  which used to keep spinning.

Closes #1895

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…members too

Review of the previous commit:

- A Cancel clicked while the loop waited on a member that never came back
  replaced the "Not back yet, check before you restart it again" toast
  with "Cancelled", hiding the one warning that matters. The uncertain
  outcome now wins; Cancelled still shows when nothing is uncertain.
- The uncertain-outcome toast counted the members that refused only
  implicitly. It now says "N instances failed to restart", from one
  helper both loops share (describeUncertainRollingOutcome).
- onSlowToReturn is queued rather than called inline, so a callback that
  throws cannot cut the watch short or become its cause.

Not adopted: counting a 401/403 probe as the target being back. Through
central manager's proxy a 403 is CM refusing the caller before it
contacts the instance (notAllowed() in HDBInstance.js), so it says
nothing about the target; recorded in DESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat it never answered

Review round 2:

- A probe can fail with an answer (a 401/403, a Harper 500), so "hasn't
  answered since" was not always true. Every surface now says Studio
  couldn't confirm the target came back, which is what a failed watch
  proves. The watch itself still counts only a successful user_info as
  back: through central manager's proxy a 403 is CM refusing the caller
  (notAllowed() in HDBInstance.js) before it contacts the instance.
- A throwing onSlowToReturn is caught and logged instead of escaping as
  an uncaught microtask exception; the watch carries on either way.
- RollingOutcome is no longer exported; it has no consumer outside its
  module.

Not adopted: dismissing the button's loading toast for an unconfirmed
restart. It already is: the catch dismisses it before deciding whether to
add its own toast. A test now pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever answered

Review round 3: DESIGN.md and restartInstance's doc comment still said the
target "never answers", which contradicts the rule that only a successful
user_info counts as back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efused config-update members

Mutation checks after the rebase onto the new restart loops found three
guards no test held: the restart request's explicit 60s timeout (the
client default is also 60s, so it only shows with that default cleared),
RESTART_MARK_TTL_MS on the Restart button and both cluster-loop marks
(restoring the old five minutes stayed green, since sleep is mocked), and
the rolling config update's failed count.

- restartInstance: the request's bound, every probe's timeout and every
  wait add up to less than RESTART_MARK_TTL_MS.
- The Restart button and the cluster loop still hold their marks six
  minutes in, past the old TTL, while the watch runs.
- A config update names a member that refused alongside the one not back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fig fan-out

After the rebase onto #1861's rolling config update, the stop rule also
decides the fan-out: a canary that is not back never proved it can run
the new configuration, so it is never fanned out from, and a stop after
the fan-out keeps the note that the members left alone may already have
the configuration saved. Both are pinned in useRollingConfigUpdate.test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay what Cancel left

Review round 1 after the port (codex major, kept by the Harper domain
adjudicator): the cluster loop waits on a member whose restart got no
verdict, but when that watch also ran out it discarded the rejection and
restarted the next member. On a two-member cluster, a 504 after the
restart ran plus a member that stays down left no member serving, which
is exactly what the stop rule exists to prevent. The loop now stops
there too, and the report says Studio couldn't confirm that member came
back either, instead of claiming it accepted the restart.

The adjudicator's nit: a Cancel after a no-verdict member now yields the
"Outcome unknown" report, which never said Cancel skipped the rest. Both
loops now name the members Cancel left alone in that report.

Also from the round: drop the test comments that only narrated their
setup, and trim restartInstance's JSDoc to its contract (DESIGN.md
carries the reasoning).

Not taken: Cancel during set_configuration still restarts that member;
that check is #1867's, unchanged here. The watch outliving the progress
toasts' durations is not a problem: sonner never starts an auto-close
timer for a loading toast (sonner 2.0.8, dist/index.mjs:644).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/1895-restart-accepted-not-back branch from 27e170c to 3633963 Compare October 13, 2026 01:41
dawsontoth and others added 2 commits October 12, 2026 21:58
…onor Cancel mid-write

Review round 2 after the port (codex major twice; the Harper domain
adjudicator kept both as minor and said the base stack could take them):

- An unanswered restart_service moved on to the next member after 3s.
  If the restart ran onto a configuration that broke the member, the
  next member got the same configuration. The config update now waits on
  that member, as the cluster restart does, and stops there if the watch
  gives up too. An unanswered set_configuration does not wait: nothing
  restarted that member.
- A Cancel clicked while a member's set_configuration was in flight still
  restarted it. The loop now stops before that restart, and the Cancelled
  report says the configuration may be pending there, as after a fan-out.
- After a Cancel, the "Waiting On Instance" toast that onSlowToReturn
  brings back offered a second Cancel, as if the first had not landed.
  Both loops now show it without the action and say no other instance
  will be restarted or updated.

Also drops the test helper comments the round flagged as narration.

Rejected on evidence: gemini's "axiosRetry resends the restart POST"
(the POST is sent once; axiosRetry wraps only the probes, and #1891's
replay never repeats a write), and "a Cancel aborts the watch into
RestartUnconfirmedError" (Cancel only sets a flag; no signal reaches the
watch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l was clicked

Review round 3 (codex minor, kept by the Harper domain adjudicator):
round 2 took the Cancel action off the slow-return toast, but the
no-verdict follow-up toast round 2 added to the config update, the
cluster loop's one, and both loops' "carry on momentarily" toasts still
came back with a Cancel button after Cancel had closed the toast, and
promised to carry on for a watch of up to about seven minutes.

One helper per loop now builds every progress toast after the first: the
usual Cancel action and copy, or, once Cancel was clicked, neither, and a
note that no other instance will be restarted or updated. Also drops a
comment that only restated its flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review October 13, 2026 02:49
@dawsontoth
dawsontoth requested a review from a team as a code owner October 13, 2026 02:49
@dawsontoth
dawsontoth merged commit 6cd6fbc into stage Oct 13, 2026
3 checks passed
@dawsontoth
dawsontoth deleted the claude/1895-restart-accepted-not-back branch October 13, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A restart Harper accepted reads as failed when the target is slow to answer again

1 participant